Follow PostHog/sdk-specs#162. An SDK sends $experiment_exposure only for
a config version 2 experiment split, so the config version, rule type,
reason code and source marker were constants on it. Require exactly
$feature_flag, $feature_flag_response, $feature_flag_variant,
$feature_flag_rule_id and locally_evaluated, and drop the other four
properties so the closed schema rejects them. The rule ID identifies
the experiment rule and marks the exposure as SDK-sent: ingestion no
longer copies a $feature_flag_called that carries config version 2
(PostHog/posthog#125449), and a copy never carries a rule ID.
A config version 2 $feature_flag_called carries $feature_flag_reason_code
in place of $feature_flag_reason. Declare $feature_flag_reason in the
called context and reject it in the version 2 branch; version 1 calls
keep it with any value.
The event schemas have no fixture sets, so meta-tests build the
projections: each exposure property missing, each forbidden exposure
property present, and $feature_flag_reason on every presence-matrix
row's version 2 call. Contract package 8.0.0.
Problem
For teams in
EXPERIMENT_EXPOSURE_DUPLICATION_TEAMS, ingestion duplicates every multivariate$feature_flag_calledevent as an$experiment_exposureevent (variant inferred from a non-boolean string$feature_flag_response).Flags on v2 rules work differently: SDKs mark their
$feature_flag_calledevents with$feature_flag_config_version: 2and send$experiment_exposurethemselves. Duplicating those calls would record each exposure twice.Changes
create-event-step.ts: a$feature_flag_calledevent with$feature_flag_config_versionequal to2is no longer duplicated. It passes through unchanged. SDKs send the number; the string"2"is matched too, because a double-counted exposure is worse than a skipped duplicate.1, keep the current behaviour.$experiment_exposurewas already never duplicated (the check requires the$feature_flag_calledevent name). A test now covers it.createCreateEventStepis the only place that duplicates. The analytics event subpipeline is the only caller that passes the allowlist (the AI and error-tracking pipelines call it without one).How did you test this code?
create-event-step.test.ts:$feature_flag_config_version2or"2": no duplicate, original event unchanged (uuid, name, properties).1or"1": duplicated as before, with the UUID pinned to the existing UUIDv5 derivation.$experiment_exposure: not duplicated.pnpm exec jest src/ingestion/common/steps/event-processing/create-event-step.test.ts: 33 passed.Rollback
Revert. Only safe while no SDK sends
$experiment_exposuredirectly for v2 rules flags; after that, a revert brings back double-counted exposures.🤖 Generated with Claude Code